page.tsx 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419
  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { useParams, useRouter } from 'next/navigation';
  4. import { useAuth } from '@/lib/auth-context';
  5. import { projectsApi, assetsApi, invitationsApi, foldersApi, Project, Asset, Invitation, TranscodeStatus, FolderNode } from '@/lib/api';
  6. import { Avatar } from '@/components/ui/avatar';
  7. import { AssetCard } from '@/components/ui/AssetCard';
  8. import { FolderTree } from '@/components/folders/FolderTree';
  9. import { ShareModal } from '@/components/share/ShareModal';
  10. import { TranscodeTasksPanel } from '@/components/transcode/TranscodeTasksPanel';
  11. import { useDropzone } from 'react-dropzone';
  12. import { useUploadQueue } from '@/contexts/UploadQueueContext';
  13. async function safeCopy(text: string): Promise<void> {
  14. if (typeof window === 'undefined') return;
  15. try {
  16. const cb = navigator.clipboard;
  17. if (cb && typeof cb.writeText === 'function') {
  18. await cb.writeText(text);
  19. } else {
  20. const el = document.createElement('textarea');
  21. el.value = text;
  22. el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
  23. document.body.appendChild(el);
  24. el.focus(); el.select();
  25. try { document.execCommand('copy'); } catch { /* ignore */ }
  26. document.body.removeChild(el);
  27. }
  28. } catch { /* ignore */ }
  29. }
  30. const ROLE_COLORS: Record<string, string> = {
  31. ADMIN: 'badge-danger',
  32. EDITOR: 'badge-brand',
  33. REVIEWER:'badge-muted',
  34. VIEWER: 'badge-subtle',
  35. };
  36. const ROLE_LABELS: Record<string, string> = {
  37. ADMIN: 'Admin',
  38. EDITOR: 'Editor',
  39. REVIEWER:'Reviewer',
  40. VIEWER: 'Viewer',
  41. };
  42. function formatGroupDate(d: Date): string {
  43. const now = new Date();
  44. const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
  45. const yesterday = new Date(today.getTime() - 86400000);
  46. const videoDay = new Date(d.getFullYear(), d.getMonth(), d.getDate());
  47. if (videoDay.getTime() === today.getTime()) return 'Today';
  48. if (videoDay.getTime() === yesterday.getTime()) return 'Yesterday';
  49. return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
  50. }
  51. function groupByDay(assets: Asset[]): [string, Asset[]][] {
  52. const sorted = [...assets].sort(
  53. (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
  54. );
  55. const groups: Record<string, Asset[]> = {};
  56. for (const a of sorted) {
  57. const d = new Date(a.createdAt);
  58. const day = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
  59. if (!groups[day]) groups[day] = [];
  60. groups[day].push(a);
  61. }
  62. return Object.entries(groups);
  63. }
  64. /** Collect asset IDs DIRECTLY in a folder (not from subfolders) */
  65. function collectAssetIds(folders: FolderNode[], targetId: string | null): Set<string> {
  66. const ids = new Set<string>();
  67. if (targetId === null) return ids; // "All Videos" — no filter
  68. function findTarget(f: FolderNode): FolderNode | null {
  69. if (f.id === targetId) return f;
  70. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  71. return null;
  72. }
  73. for (const f of folders) {
  74. const target = findTarget(f);
  75. if (target) { for (const id of target.assetIds) ids.add(id); break; }
  76. }
  77. return ids;
  78. }
  79. /** Get direct subfolders of a folder */
  80. function getSubfolders(folders: FolderNode[], targetId: string | null): FolderNode[] {
  81. if (targetId === null) return folders; // root: show top-level folders
  82. function findTarget(f: FolderNode): FolderNode | null {
  83. if (f.id === targetId) return f;
  84. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  85. return null;
  86. }
  87. for (const f of folders) {
  88. const target = findTarget(f);
  89. if (target) return [...target.children].sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
  90. }
  91. return [];
  92. }
  93. /** Build a map of assetId -> single deepest folder name */
  94. function buildAssetFolders(allFolders: FolderNode[]): Map<string, string> {
  95. const map = new Map<string, string>();
  96. const depthMap = new Map<string, number>();
  97. function search(f: FolderNode, depth: number): void {
  98. for (const id of f.assetIds) {
  99. const existingDepth = depthMap.get(id) ?? -1;
  100. if (depth > existingDepth) {
  101. map.set(id, f.name);
  102. depthMap.set(id, depth);
  103. }
  104. }
  105. for (const child of f.children) search(child, depth + 1);
  106. }
  107. for (const f of allFolders) search(f, 0);
  108. return map;
  109. }
  110. /** Get the folder name an asset belongs to (deepest only) */
  111. function getAssetFolderNames(assetFolders: Map<string, string>, assetId: string): string[] {
  112. const name = assetFolders.get(assetId);
  113. return name ? [name] : [];
  114. }
  115. /** Returns a breadcrumb path of folder names for the selected folder */
  116. function getBreadcrumb(folders: FolderNode[], targetId: string | null): string[] {
  117. if (targetId === null) return [];
  118. const path: string[] = [];
  119. function search(f: FolderNode, trail: string[]): boolean {
  120. if (f.id === targetId) { path.push(...trail, f.name); return true; }
  121. for (const child of f.children) {
  122. if (search(child, [...trail, f.name])) return true;
  123. }
  124. return false;
  125. }
  126. for (const f of folders) if (search(f, [])) break;
  127. return path;
  128. }
  129. export default function ProjectDetailPage() {
  130. const params = useParams();
  131. const projectId = params.projectId as string;
  132. const { user, token } = useAuth();
  133. const router = useRouter();
  134. const [project, setProject] = useState<Project | null>(null);
  135. const [members, setMembers] = useState<any[]>([]);
  136. const [pendingInvites, setPendingInvites] = useState<Invitation[]>([]);
  137. const [assets, setAssets] = useState<Asset[]>([]);
  138. const [folders, setFolders] = useState<FolderNode[]>([]);
  139. const [allFolders, setAllFolders] = useState<FolderNode[]>([]);
  140. const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
  141. const [viewMode, setViewMode] = useState<'file' | 'timeline'>('file');
  142. const [loading, setLoading] = useState(true);
  143. const [sharingAssetId, setSharingAssetId] = useState<string | null>(null);
  144. const [activeTab, setActiveTab] = useState<'videos' | 'members' | 'transcode'>('videos');
  145. // Invite form state (single shared form)
  146. const [inviteEmail, setInviteEmail] = useState('');
  147. const [inviteRole, setInviteRole] = useState('REVIEWER');
  148. const [inviting, setInviting] = useState(false);
  149. const [inviteError, setInviteError] = useState('');
  150. const [inviteSuccess, setInviteSuccess] = useState('');
  151. const [createdLink, setCreatedLink] = useState('');
  152. const [createdLinkEmail, setCreatedLinkEmail] = useState('');
  153. const [linkCopiedAgain, setLinkCopiedAgain] = useState(false);
  154. // Edit member role
  155. const [editingRoleId, setEditingRoleId] = useState<string | null>(null);
  156. const [editingRole, setEditingRole] = useState('');
  157. const [updatingRole, setUpdatingRole] = useState(false);
  158. // Remove member
  159. const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null);
  160. const [removing, setRemoving] = useState(false);
  161. // Revoke invite
  162. const [revokingId, setRevokingId] = useState<string | null>(null);
  163. // Copy link
  164. const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
  165. const [inviteUrlMap, setInviteUrlMap] = useState<Record<string, string>>({});
  166. const [reprocessingAll, setReprocessingAll] = useState(false);
  167. const [globalStuckCount, setGlobalStuckCount] = useState(0);
  168. const canManage = members.some(m =>
  169. m.user.id === user?.id && ['ADMIN', 'EDITOR'].includes(m.role)
  170. );
  171. const isOwner = project?.ownerId === user?.id;
  172. const isAdmin = user?.globalRole === 'ADMIN';
  173. // Poll workspace-wide stuck job count every 30s (admins + editors can use it)
  174. useEffect(() => {
  175. if ((!isAdmin && !canManage) || !token) return;
  176. let cancelled = false;
  177. async function fetchStuckCount() {
  178. const t = token as string;
  179. try {
  180. const data = await assetsApi.getStuckCount(t, projectId as string);
  181. if (!cancelled) setGlobalStuckCount(data.count ?? 0);
  182. } catch {}
  183. }
  184. fetchStuckCount();
  185. const id = setInterval(fetchStuckCount, 30_000);
  186. return () => { cancelled = true; clearInterval(id); };
  187. }, [isAdmin, canManage, token]);
  188. // ── Folder data derived from state ──────────────────────────────────────────
  189. // For file mode: only assets directly in the selected folder
  190. const folderAssetIds = assets.length > 0
  191. ? collectAssetIds(folders, selectedFolderId)
  192. : new Set<string>();
  193. // For timeline mode: assets in selected folder AND all its subfolders
  194. const timelineAssetIds = (() => {
  195. const ids = new Set<string>();
  196. if (selectedFolderId === null) return ids;
  197. function findTarget(f: FolderNode): FolderNode | null {
  198. if (f.id === selectedFolderId) return f;
  199. for (const c of f.children) { const r = findTarget(c); if (r) return r; }
  200. return null;
  201. }
  202. function collectAll(f: FolderNode): void {
  203. for (const id of f.assetIds) ids.add(id);
  204. for (const c of f.children) collectAll(c);
  205. }
  206. for (const f of folders) {
  207. const target = findTarget(f);
  208. if (target) { collectAll(target); break; }
  209. }
  210. return ids;
  211. })();
  212. const filteredAssets = selectedFolderId === null
  213. ? assets
  214. : (folderAssetIds.size > 0 ? assets.filter(a => folderAssetIds.has(a.id)) : []);
  215. // Timeline uses all assets in the selected folder AND its subfolders
  216. const timelineAssets = selectedFolderId === null
  217. ? assets
  218. : (timelineAssetIds.size > 0 ? assets.filter(a => timelineAssetIds.has(a.id)) : []);
  219. const subfolders = getSubfolders(folders, selectedFolderId);
  220. const breadcrumb = getBreadcrumb(folders, selectedFolderId);
  221. const assetFolders = buildAssetFolders(allFolders);
  222. // ── Delete project ──────────────────────────────────────────────────────────
  223. const [confirmDeleteProject, setConfirmDeleteProject] = useState(false);
  224. const [deletingProject, setDeletingProject] = useState(false);
  225. const handleDeleteProject = async () => {
  226. if (!token) return;
  227. setDeletingProject(true);
  228. try {
  229. await projectsApi.delete(token, projectId);
  230. router.push('/projects');
  231. } catch (err) {
  232. alert(err instanceof Error ? err.message : 'Failed to delete project');
  233. } finally {
  234. setDeletingProject(false);
  235. setConfirmDeleteProject(false);
  236. }
  237. };
  238. const loadFolders = useCallback(async () => {
  239. if (!token) return;
  240. try {
  241. const data = await foldersApi.list(token, projectId);
  242. setFolders(data.folders);
  243. setAllFolders(data.allFolders);
  244. } catch (e) {
  245. console.error('Failed to load folders:', e);
  246. }
  247. }, [token, projectId]);
  248. const loadAll = useCallback(async () => {
  249. if (!token) return;
  250. try {
  251. const [{ project: p }, { assets: a }] = await Promise.all([
  252. projectsApi.get(token, projectId),
  253. assetsApi.list(token, projectId),
  254. ]);
  255. setProject(p);
  256. setMembers(p.members ?? []);
  257. setAssets(a);
  258. if (canManage) {
  259. const { invitations } = await invitationsApi.list(token, projectId);
  260. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  261. }
  262. } catch {
  263. router.push('/projects');
  264. } finally {
  265. setLoading(false);
  266. }
  267. }, [token, projectId, router, canManage]);
  268. useEffect(() => { loadAll(); }, [loadAll]);
  269. useEffect(() => { if (!loading && token) loadFolders(); }, [loading, token, loadFolders]);
  270. // ── Invite member ──────────────────────────────────────────────────────────
  271. const handleInvite = async (e: React.FormEvent) => {
  272. e.preventDefault();
  273. if (!token || !inviteEmail.trim()) return;
  274. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.trim())) {
  275. setInviteError('Invalid email address');
  276. return;
  277. }
  278. setInviting(true);
  279. setInviteError('');
  280. setInviteSuccess('');
  281. setCreatedLink('');
  282. try {
  283. const { inviteUrl } = await invitationsApi.create(token, projectId, inviteEmail.trim(), inviteRole);
  284. const { invitations } = await invitationsApi.list(token, projectId);
  285. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  286. setInviteUrlMap(prev => ({ ...prev, [inviteUrl.split('/').pop()!]: inviteUrl }));
  287. setInviteEmail('');
  288. setInviteSuccess(`Invitation sent to ${inviteEmail.trim()}`);
  289. setTimeout(() => setInviteSuccess(''), 3000);
  290. } catch (err) {
  291. setInviteError(err instanceof Error ? err.message : 'Failed to send invitation');
  292. } finally {
  293. setInviting(false);
  294. }
  295. };
  296. // ── Create & copy link ─────────────────────────────────────────────────────
  297. const handleCreateLink = async () => {
  298. if (!token || !inviteEmail.trim()) return;
  299. setInviting(true);
  300. setInviteError('');
  301. setInviteSuccess('');
  302. setCreatedLink('');
  303. setLinkCopiedAgain(false);
  304. const email = inviteEmail.trim();
  305. try {
  306. const { inviteUrl } = await invitationsApi.create(token, projectId, email, inviteRole);
  307. const { invitations } = await invitationsApi.list(token, projectId);
  308. setPendingInvites(invitations.filter((i: Invitation) => i.status === 'PENDING'));
  309. await safeCopy(inviteUrl);
  310. setCreatedLink(inviteUrl);
  311. setCreatedLinkEmail(email);
  312. setInviteEmail('');
  313. } catch (err: any) {
  314. const msg = err instanceof Error ? err.message : String(err);
  315. if (msg.includes('already exists') || msg.includes('already a member') || msg.includes('409')) {
  316. setInviteError(`An invitation for "${email}" is already pending or the user is already a member.`);
  317. } else {
  318. setInviteError(msg || 'Failed to create invitation link');
  319. }
  320. } finally {
  321. setInviting(false);
  322. }
  323. };
  324. // ── Change role ────────────────────────────────────────────────────────────
  325. const handleChangeRole = async (memberId: string) => {
  326. if (!token || !editingRole) return;
  327. setUpdatingRole(true);
  328. try {
  329. await projectsApi.updateMember(token, projectId, memberId, editingRole);
  330. setMembers(prev => prev.map(m => m.id === memberId ? { ...m, role: editingRole } : m));
  331. setEditingRoleId(null);
  332. } catch (err) {
  333. alert(err instanceof Error ? err.message : 'Failed to update role');
  334. } finally {
  335. setUpdatingRole(false);
  336. }
  337. };
  338. // ── Remove member ─────────────────────────────────────────────────────────
  339. const handleRemoveMember = async () => {
  340. if (!token || !confirmRemove) return;
  341. setRemoving(true);
  342. try {
  343. await projectsApi.removeMember(token, projectId, confirmRemove.id);
  344. setMembers(prev => prev.filter(m => m.id !== confirmRemove!.id));
  345. setConfirmRemove(null);
  346. } catch (err) {
  347. alert(err instanceof Error ? err.message : 'Failed to remove member');
  348. } finally {
  349. setRemoving(false);
  350. }
  351. };
  352. // ── Revoke invite ──────────────────────────────────────────────────────────
  353. const handleRevoke = async (invitationId: string) => {
  354. if (!token) return;
  355. setRevokingId(invitationId);
  356. try {
  357. await invitationsApi.revoke(token, invitationId);
  358. setPendingInvites(prev => prev.filter(i => i.id !== invitationId));
  359. } catch (err) {
  360. alert(err instanceof Error ? err.message : 'Failed to revoke invitation');
  361. } finally {
  362. setRevokingId(null);
  363. }
  364. };
  365. // ── Copy invite link ──────────────────────────────────────────────────────
  366. const handleCopyLink = async (invite: Invitation) => {
  367. const base = window.location.origin;
  368. const url = inviteUrlMap[invite.token] ?? `${base}/invite/${invite.token}`;
  369. await safeCopy(url);
  370. setCopiedInviteId(invite.id);
  371. setTimeout(() => setCopiedInviteId(null), 2000);
  372. };
  373. // ── Upload ─────────────────────────────────────────────────────────────────
  374. const { enqueue, totalActive } = useUploadQueue();
  375. const handleDrop = (acceptedFiles: File[]) => {
  376. if (!token || acceptedFiles.length === 0) return;
  377. for (const file of acceptedFiles) {
  378. enqueue({
  379. projectId,
  380. folderId: selectedFolderId ?? undefined,
  381. file,
  382. });
  383. }
  384. };
  385. const { getRootProps: getUploadRootProps, getInputProps: getUploadInputProps, isDragActive: isUploadDragActive } = useDropzone({
  386. onDrop: handleDrop,
  387. accept: { 'video/*': ['.mp4', '.mov', '.webm', '.avi', '.mpeg'] },
  388. multiple: true,
  389. disabled: totalActive > 0,
  390. });
  391. // Poll for assets that are still processing
  392. const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
  393. // ── Delete asset ─────────────────────────────────────────────────────────
  394. const [confirmDelete, setConfirmDelete] = useState<{ id: string; title: string } | null>(null);
  395. const [deletingId, setDeletingId] = useState<string | null>(null);
  396. const handleDeleteAsset = (id: string, title: string) => {
  397. setConfirmDelete({ id, title });
  398. };
  399. // ── Remove asset from a folder ──────────────────────────────────────────
  400. const handleRemoveFromFolder = useCallback(async (assetId: string, folderName: string) => {
  401. if (!token) return;
  402. // Find the folder by name within the project
  403. const folder = allFolders.find(f => f.name === folderName);
  404. if (!folder) return;
  405. try {
  406. await foldersApi.removeAsset(token, folder.id, assetId);
  407. // Refresh folder data so asset disappears from the folder
  408. loadFolders();
  409. } catch (err) {
  410. console.error('Failed to remove from folder:', err);
  411. }
  412. }, [token, allFolders, loadFolders]);
  413. const confirmDeleteAsset = async () => {
  414. if (!token || !confirmDelete) return;
  415. setDeletingId(confirmDelete.id);
  416. try {
  417. await assetsApi.delete(token, confirmDelete.id);
  418. setAssets(prev => prev.filter(a => a.id !== confirmDelete.id));
  419. setConfirmDelete(null);
  420. } catch (err) {
  421. alert(err instanceof Error ? err.message : 'Failed to delete video');
  422. } finally {
  423. setDeletingId(null);
  424. }
  425. };
  426. useEffect(() => {
  427. const processingAssets = assets.filter(a =>
  428. ['UPLOADING', 'PROCESSING', 'PENDING'].includes(a.transcodeStatus)
  429. );
  430. if (processingAssets.length === 0) {
  431. if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
  432. return;
  433. }
  434. if (pollingRef.current) return;
  435. pollingRef.current = setInterval(async () => {
  436. if (!token) return;
  437. try {
  438. const { assets: updated } = await assetsApi.list(token, projectId);
  439. setAssets(updated);
  440. } catch {}
  441. }, 3000);
  442. return () => { if (pollingRef.current) clearInterval(pollingRef.current); };
  443. }, [token, projectId, assets]);
  444. if (loading) {
  445. return (
  446. <div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg)' }}>
  447. <div className="flex items-center gap-3" style={{ color: 'var(--text-muted)' }}>
  448. <div className="w-5 h-5 rounded-full animate-spin"
  449. style={{ borderColor: '#6366F1', borderTopColor: 'transparent' }} />
  450. <span className="text-sm">Loading…</span>
  451. </div>
  452. </div>
  453. );
  454. }
  455. return (
  456. <div className="min-h-screen" style={{ background: 'var(--bg)' }}>
  457. {/* Full-page upload overlay when dragging files */}
  458. {isUploadDragActive && (
  459. <div {...getUploadRootProps()} className="upload-drop-overlay">
  460. <input {...getUploadInputProps()} />
  461. <div className="text-center">
  462. <div className="w-16 h-16 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  463. style={{ background: 'rgba(99,102,241,0.15)', border: '2px solid rgba(99,102,241,0.4)' }}>
  464. <svg className="w-8 h-8" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  465. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  466. </svg>
  467. </div>
  468. <p className="text-lg font-medium" style={{ color: 'var(--text)' }}>Drop videos to upload</p>
  469. <p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>MP4, MOV, WebM — up to 500MB each</p>
  470. </div>
  471. </div>
  472. )}
  473. {/* Header */}
  474. <header className="sticky top-0 z-10 px-4 md:px-8 py-3 md:py-4 flex items-center gap-2 md:gap-4 shrink-0 flex-wrap"
  475. style={{
  476. background: 'rgba(10,11,20,0.80)',
  477. backdropFilter: 'blur(12px)',
  478. borderBottom: '1px solid rgba(255,255,255,0.06)',
  479. }}>
  480. <button
  481. onClick={() => router.push('/projects')}
  482. className="flex items-center gap-1.5 text-sm transition-colors shrink-0"
  483. style={{ color: 'var(--text-muted)' }}
  484. >
  485. <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  486. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
  487. </svg>
  488. <span className="hidden sm:inline">Projects</span>
  489. </button>
  490. <div className="w-px h-4 hidden sm:block shrink-0" style={{ background: 'rgba(255,255,255,0.10)' }} />
  491. <div className="flex-1 min-w-0">
  492. <div className="flex items-center gap-2">
  493. <h1 className="text-sm font-semibold truncate" style={{ color: 'var(--text)' }}>
  494. {project?.name}
  495. </h1>
  496. {canManage && (
  497. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  498. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  499. {isAdmin ? 'Owner' : 'Editor'}
  500. </span>
  501. )}
  502. {!canManage && !isAdmin && (
  503. <span className="text-[10px] px-1.5 py-0.5 rounded-full shrink-0"
  504. style={{ background: 'rgba(255,255,255,0.04)', color: 'var(--text-subtle)' }}>
  505. {members.find(m => m.user.id === user?.id)?.role ?? 'Member'}
  506. </span>
  507. )}
  508. </div>
  509. {project?.description && (
  510. <p className="text-xs truncate mt-0.5 hidden sm:block" style={{ color: 'var(--text-muted)' }}>
  511. {project.description}
  512. </p>
  513. )}
  514. </div>
  515. {/* Upload button — compact, in header */}
  516. {canManage && (
  517. <button
  518. {...getUploadRootProps()}
  519. className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg shrink-0 transition-all"
  520. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}
  521. title="Upload video"
  522. >
  523. <input {...getUploadInputProps()} />
  524. {totalActive > 0 ? (
  525. <div className="w-3.5 h-3.5 rounded-full animate-spin" style={{ borderColor: '#A5B4FC', borderTopColor: 'transparent', borderWidth: '2px' }} />
  526. ) : (
  527. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  528. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  529. </svg>
  530. )}
  531. <span className="hidden sm:inline">Upload</span>
  532. </button>
  533. )}
  534. {/* Tabs */}
  535. <div className="flex items-center gap-1 p-1 rounded-lg shrink-0"
  536. style={{ background: 'rgba(255,255,255,0.04)' }}>
  537. {[
  538. { tab: 'videos', label: 'Videos', count: assets.length },
  539. { tab: 'transcode', label: 'Tasks', count: assets.filter(a => a.transcodeStatus !== 'COMPLETED').length },
  540. { tab: 'members', label: 'Members', count: members.length },
  541. ].map(({ tab, label, count }) => (
  542. <button key={tab}
  543. onClick={() => setActiveTab(tab as any)}
  544. className="relative px-2 sm:px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 shrink-0"
  545. style={{
  546. background: activeTab === tab ? 'rgba(99,102,241,0.20)' : 'transparent',
  547. color: activeTab === tab ? '#A5B4FC' : 'var(--text-muted)',
  548. }}
  549. title={label}
  550. >
  551. {tab === 'videos' && (
  552. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  553. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  554. </svg>
  555. )}
  556. {tab === 'transcode' && (
  557. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  558. <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
  559. </svg>
  560. )}
  561. {tab === 'members' && (
  562. <svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  563. <path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
  564. </svg>
  565. )}
  566. <span className="hidden sm:inline">{label}</span>
  567. <span className="text-[10px] px-1 py-0.5 rounded-full"
  568. style={{
  569. background: tab === 'transcode'
  570. ? 'rgba(167,139,250,0.25)'
  571. : 'rgba(255,255,255,0.06)',
  572. color: tab === 'transcode' ? '#A78BFA' : 'inherit',
  573. }}>
  574. {count}
  575. </span>
  576. </button>
  577. ))}
  578. </div>
  579. {/* Delete project — owner only */}
  580. {isOwner && (
  581. <button
  582. onClick={() => setConfirmDeleteProject(true)}
  583. className="flex items-center justify-center p-1.5 rounded-lg transition-all shrink-0"
  584. style={{ color: '#F87171', background: 'rgba(248,113,113,0.08)' }}
  585. title="Delete project"
  586. >
  587. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  588. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  589. </svg>
  590. </button>
  591. )}
  592. </header>
  593. <div className="px-4 md:px-8 py-4 md:py-6">
  594. {/* ── Videos Tab ───────────────────────────────────────────────────── */}
  595. {activeTab === 'videos' && (
  596. <>
  597. {/* File/Timeline mode toggle + breadcrumb bar */}
  598. {activeTab === 'videos' && (
  599. <div className="flex items-center gap-3 mb-5 flex-wrap">
  600. {/* Breadcrumb */}
  601. <nav className="flex items-center gap-1 text-xs shrink min-w-0" style={{ color: 'var(--text-muted)' }}>
  602. <span className="truncate">{project?.name}</span>
  603. {breadcrumb.map((name, i) => (
  604. <span key={i} className="flex items-center gap-1 shrink-0">
  605. <svg className="w-3 h-3 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  606. <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
  607. </svg>
  608. <span className={i === breadcrumb.length - 1 ? '' : 'opacity-60'}>{name}</span>
  609. </span>
  610. ))}
  611. </nav>
  612. <div className="flex-1" />
  613. {/* Asset count */}
  614. <span className="text-xs px-2 py-1 rounded-full shrink-0"
  615. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  616. {filteredAssets.length} video{filteredAssets.length !== 1 ? 's' : ''}
  617. </span>
  618. {/* Mode toggle */}
  619. <div className="flex items-center gap-0.5 p-0.5 rounded-lg shrink-0"
  620. style={{ background: 'rgba(255,255,255,0.05)' }}>
  621. {[
  622. { mode: 'file' as const, label: 'File', icon: (
  623. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  624. <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-1.5A2.25 2.25 0 0115 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
  625. </svg>
  626. )},
  627. { mode: 'timeline' as const, label: 'Timeline', icon: (
  628. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  629. <path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
  630. </svg>
  631. )},
  632. ].map(({ mode, label, icon }) => (
  633. <button key={mode}
  634. onClick={() => setViewMode(mode)}
  635. className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all whitespace-nowrap"
  636. style={{
  637. background: viewMode === mode ? 'rgba(99,102,241,0.20)' : 'transparent',
  638. color: viewMode === mode ? '#A5B4FC' : 'var(--text-muted)',
  639. }}
  640. >
  641. {icon}
  642. <span className="hidden sm:inline">{label}</span>
  643. </button>
  644. ))}
  645. </div>
  646. </div>
  647. )}
  648. <div className="flex gap-5">
  649. {/* Left panel: FolderTree (both file and timeline modes) */}
  650. <aside className="w-52 shrink-0 hidden md:block">
  651. <FolderTree
  652. folders={folders}
  653. allFolders={allFolders}
  654. selectedFolderId={selectedFolderId}
  655. onSelectFolder={setSelectedFolderId}
  656. canManage={canManage}
  657. token={token ?? ''}
  658. projectId={projectId}
  659. onRefresh={loadFolders}
  660. totalAssetCount={assets.length}
  661. onFilesDropped={(files, folderId) => {
  662. for (const file of files) {
  663. enqueue({ projectId, folderId, file });
  664. }
  665. }}
  666. />
  667. </aside>
  668. {/* Main content */}
  669. <div className="flex-1 min-w-0">
  670. {/* Upload zone for non-managers */}
  671. {!canManage && (
  672. <div className="mb-6 rounded-2xl p-6 text-center animate-fade-in"
  673. style={{ background: 'rgba(255,255,255,0.01)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '16px' }}>
  674. <div className="w-10 h-10 rounded-2xl mx-auto mb-3 flex items-center justify-center"
  675. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}>
  676. <svg className="w-5 h-5" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  677. <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
  678. </svg>
  679. </div>
  680. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  681. Your role ({members.find(m => m.user.id === user?.id)?.role ?? 'Member'}) does not allow uploading.
  682. </p>
  683. </div>
  684. )}
  685. {/* File mode content */}
  686. {viewMode === 'file' && (filteredAssets.length === 0 && subfolders.length === 0) ? (
  687. <div className="text-center py-16 rounded-2xl animate-fade-in"
  688. style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}>
  689. <div className="w-14 h-14 rounded-2xl mx-auto mb-4 flex items-center justify-center"
  690. style={{ background: 'rgba(99,102,241,0.08)', border: '1px solid rgba(99,102,241,0.12)' }}>
  691. <svg className="w-7 h-7" style={{ color: '#6366F1' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.2}>
  692. <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
  693. </svg>
  694. </div>
  695. <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
  696. {selectedFolderId ? 'No videos in this folder' : 'No videos yet'}
  697. </p>
  698. <p className="text-xs" style={{ color: 'var(--text-muted)' }}>
  699. {selectedFolderId
  700. ? 'Drag videos here or move them from other folders'
  701. : (canManage ? 'Upload your first video using the Upload button above' : 'Videos will appear here once uploaded')}
  702. </p>
  703. </div>
  704. ) : viewMode === 'file' ? (
  705. // File mode: subfolders + videos
  706. <div>
  707. {/* Subfolders */}
  708. {subfolders.length > 0 && (
  709. <div className="mb-6">
  710. <div className="flex items-center gap-3 mb-3">
  711. <span className="text-xs font-medium" style={{ color: 'var(--text-subtle)' }}>Folders</span>
  712. <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.05)' }} />
  713. </div>
  714. <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 gap-3">
  715. {subfolders.map(folder => (
  716. <button
  717. key={folder.id}
  718. onClick={() => setSelectedFolderId(folder.id)}
  719. className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-left transition-all hover:brightness-110 group"
  720. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)' }}
  721. >
  722. <svg className="w-5 h-5 shrink-0" style={{ color: '#A78BFA' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  723. <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9A2.25 2.25 0 0019.5 6.75h-1.5A2.25 2.25 0 0115.75 9v.75m-8.69-6.44H5.5a2.25 2.25 0 00-2.25 2.25v.75h13.5v-.75a2.25 2.25 0 00-2.25-2.25H12" />
  724. </svg>
  725. <div className="flex-1 min-w-0">
  726. <div className="text-xs font-medium truncate" style={{ color: 'var(--text)' }}>{folder.name}</div>
  727. {folder.assetCount > 0 && (
  728. <div className="text-[10px]" style={{ color: 'var(--text-subtle)' }}>{folder.assetCount} video{folder.assetCount !== 1 ? 's' : ''}</div>
  729. )}
  730. </div>
  731. <svg className="w-3 h-3 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" style={{ color: 'var(--text-subtle)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  732. <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
  733. </svg>
  734. </button>
  735. ))}
  736. </div>
  737. </div>
  738. )}
  739. {/* Videos in this folder */}
  740. {filteredAssets.length > 0 && (
  741. <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
  742. {filteredAssets.map((asset, i) => (
  743. <AssetCard
  744. key={asset.id}
  745. asset={asset}
  746. canManage={canManage}
  747. showHour={false}
  748. onPlay={() => router.push(`/review/${asset.id}`)}
  749. onDelete={() => handleDeleteAsset(asset.id, asset.title)}
  750. onCancel={async (id) => {
  751. if (!token) return;
  752. try {
  753. await assetsApi.cancelTranscode(token, id);
  754. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  755. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
  756. }}
  757. onPause={async (id) => {
  758. if (!token) return;
  759. try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
  760. catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
  761. }}
  762. onResume={async (id) => {
  763. if (!token) return;
  764. try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
  765. catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
  766. }}
  767. animationDelay={i * 40}
  768. folderNames={getAssetFolderNames(assetFolders, asset.id)}
  769. onShare={setSharingAssetId}
  770. isShared={!!asset.isShared}
  771. onRemoveFromFolder={handleRemoveFromFolder}
  772. />
  773. ))}
  774. </div>
  775. )}
  776. </div>
  777. ) : (
  778. // Timeline mode: grouped by date
  779. <div className="space-y-8">
  780. {groupByDay(timelineAssets).map(([dayKey, dayAssets]) => {
  781. const groupDate = new Date(dayKey);
  782. const showHour = dayAssets.length > 1;
  783. return (
  784. <div key={dayKey}>
  785. <div className="flex items-center gap-3 mb-4">
  786. <span className="text-xs font-semibold shrink-0" style={{ color: 'var(--text-muted)' }}>
  787. {formatGroupDate(groupDate)}
  788. </span>
  789. <div className="flex-1 h-px" style={{ background: 'rgba(255,255,255,0.06)' }} />
  790. <span className="text-[10px] shrink-0" style={{ color: 'var(--text-subtle)' }}>
  791. {dayAssets.length} video{dayAssets.length !== 1 ? 's' : ''}
  792. </span>
  793. </div>
  794. <div className="space-y-3">
  795. {dayAssets.map((asset, i) => {
  796. const createdAt = new Date(asset.createdAt);
  797. return (
  798. <div key={asset.id}
  799. className="flex items-center gap-4 p-3 rounded-xl cursor-pointer group transition-colors animate-fade-in"
  800. style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
  801. onClick={() => router.push(`/review/${asset.id}`)}
  802. draggable={canManage}
  803. onDragStart={canManage ? (e) => {
  804. e.dataTransfer.setData('assetId', asset.id);
  805. e.dataTransfer.setData('text/plain', asset.title);
  806. e.dataTransfer.effectAllowed = 'move';
  807. if (asset.thumbnail && asset.transcodeStatus === 'COMPLETED') {
  808. const ghost = document.createElement('div');
  809. ghost.style.cssText = 'position:fixed;top:-9999px;left:-9999px;display:flex;align-items:center;gap:8px;padding:6px 10px;background:rgba(15,15,25,0.95);border:1px solid rgba(99,102,241,0.4);border-radius:8px;backdrop-filter:blur(8px);font-family:system-ui,sans-serif;z-index:99999;';
  810. const img = document.createElement('img');
  811. img.src = `/uploads/${asset.thumbnail}`;
  812. img.style.cssText = 'height:48px;border-radius:5px;object-fit:cover;';
  813. const label = document.createElement('span');
  814. label.textContent = asset.title;
  815. label.style.cssText = 'color:#e2e8f0;font-size:12px;font-weight:500;max-width:160px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;';
  816. ghost.appendChild(img);
  817. ghost.appendChild(label);
  818. document.body.appendChild(ghost);
  819. e.dataTransfer.setDragImage(ghost, 30, 28);
  820. setTimeout(() => document.body.removeChild(ghost), 0);
  821. }
  822. } : undefined}
  823. >
  824. {/* Thumbnail */}
  825. <div className="w-24 sm:w-32 shrink-0 rounded-lg overflow-hidden aspect-video"
  826. style={{ background: '#080810' }}>
  827. {asset.thumbnail && asset.transcodeStatus === 'COMPLETED' ? (
  828. <img src={`/uploads/${asset.thumbnail}`} alt={asset.title} className="w-full h-full object-cover" style={{ opacity: 0.8 }} />
  829. ) : (
  830. <div className="w-full h-full flex items-center justify-center">
  831. <svg className="w-6 h-6" style={{ color: 'rgba(255,255,255,0.15)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
  832. <path strokeLinecap="round" strokeLinejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
  833. <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  834. </svg>
  835. </div>
  836. )}
  837. </div>
  838. {/* Info */}
  839. <div className="flex-1 min-w-0">
  840. <div className="flex items-start justify-between gap-2 mb-1">
  841. <h3 className="text-sm font-medium truncate" style={{ color: 'var(--text)' }}>{asset.title}</h3>
  842. {asset.duration && (
  843. <span className="text-xs shrink-0 px-1.5 py-0.5 rounded font-mono"
  844. style={{ background: 'rgba(0,0,0,0.5)', color: '#E2E8F0' }}>
  845. {`${Math.floor(asset.duration / 60)}:${Math.floor(asset.duration % 60).toString().padStart(2, '0')}`}
  846. </span>
  847. )}
  848. </div>
  849. {/* Folder tags */}
  850. {(() => {
  851. const tags = getAssetFolderNames(assetFolders, asset.id);
  852. return tags.length > 0 ? (
  853. <div className="flex flex-wrap gap-1 mb-1">
  854. {tags.map((name, i) => (
  855. <span key={i} className="text-[10px] px-1.5 py-0.5 rounded"
  856. style={{ background: 'rgba(99,102,241,0.12)', color: '#A5B4FC' }}>
  857. {name}
  858. </span>
  859. ))}
  860. </div>
  861. ) : null;
  862. })()}
  863. <div className="flex items-center gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
  864. <span className="truncate">{asset.uploader?.name ?? 'Unknown'}</span>
  865. <span>·</span>
  866. <span className="shrink-0 text-[10px]" style={{ color: 'var(--text-subtle)' }}>
  867. {showHour
  868. ? createdAt.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
  869. : createdAt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
  870. </span>
  871. <span>·</span>
  872. <span>{(asset as any)._count?.comments ?? 0} comments</span>
  873. </div>
  874. </div>
  875. {/* Play button */}
  876. <div className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
  877. style={{ background: 'rgba(99,102,241,0.20)', color: '#A5B4FC' }}>
  878. <svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
  879. <path d="M8 5v14l11-7z" />
  880. </svg>
  881. </div>
  882. </div>
  883. );
  884. })}
  885. </div>
  886. </div>
  887. );
  888. })}
  889. </div>
  890. )}
  891. </div>
  892. </div>
  893. </>
  894. )}
  895. {/* ── Transcode Tasks Tab ─────────────────────────────────────────── */}
  896. {activeTab === 'transcode' && (
  897. <div className="animate-fade-in">
  898. <TranscodeTasksPanel
  899. assets={assets}
  900. token={token}
  901. canManage={canManage}
  902. isAdmin={isAdmin}
  903. onDelete={handleDeleteAsset}
  904. onCancel={async (id) => {
  905. if (!token) return;
  906. try {
  907. await assetsApi.cancelTranscode(token, id);
  908. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  909. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to cancel transcode'); }
  910. }}
  911. onPause={async (id) => {
  912. if (!token) return;
  913. try { await assetsApi.pauseTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: true } : a)); }
  914. catch (err) { alert(err instanceof Error ? err.message : 'Failed to pause transcode'); }
  915. }}
  916. onResume={async (id) => {
  917. if (!token) return;
  918. try { await assetsApi.resumeTranscode(token, id); setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodePaused: false } : a)); }
  919. catch (err) { alert(err instanceof Error ? err.message : 'Failed to resume transcode'); }
  920. }}
  921. onReprocess={async (id) => {
  922. if (!token) return;
  923. try {
  924. await assetsApi.cancelTranscode(token, id);
  925. setAssets(prev => prev.map(a => a.id === id ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0, transcodeError: null, hlsPath: null, transcodePaused: false } : a));
  926. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reprocess transcode'); }
  927. }}
  928. onReprocessAll={async () => {
  929. if (!token) return;
  930. setReprocessingAll(true);
  931. try {
  932. const result = await assetsApi.reprocessAll(token, projectId as string);
  933. // Reset all PROCESSING assets in local state
  934. setAssets(prev => prev.map(a =>
  935. a.transcodeStatus === 'PROCESSING'
  936. ? { ...a, transcodeStatus: 'PENDING', transcodeProgress: 0 }
  937. : a
  938. ));
  939. alert(result.message);
  940. } catch (err) { alert(err instanceof Error ? err.message : 'Failed to reset stuck jobs'); }
  941. finally { setReprocessingAll(false); }
  942. }}
  943. isReprocessingAll={reprocessingAll}
  944. globalStuckCount={globalStuckCount}
  945. />
  946. </div>
  947. )}
  948. {/* ── Members Tab ─────────────────────────────────────────────────── */}
  949. {activeTab === 'members' && (
  950. <div className="max-w-3xl animate-fade-in">
  951. {/* Invite form */}
  952. {canManage && (
  953. <div className="card p-5 mb-6">
  954. <h2 className="text-sm font-semibold mb-4" style={{ color: 'var(--text)' }}>
  955. Invite someone
  956. </h2>
  957. <div className="space-y-3">
  958. <form
  959. onSubmit={e => { e.preventDefault(); handleInvite(e); }}
  960. className="flex items-end gap-3 flex-wrap"
  961. >
  962. <div className="flex-1 min-w-[180px]">
  963. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>
  964. Email address
  965. </label>
  966. <input
  967. type="email"
  968. className="input"
  969. value={inviteEmail}
  970. onChange={e => setInviteEmail(e.target.value)}
  971. placeholder="colleague@company.com"
  972. />
  973. </div>
  974. <div className="w-36">
  975. <label className="block text-xs mb-1.5" style={{ color: 'var(--text-muted)' }}>Role</label>
  976. <select
  977. className="input"
  978. value={inviteRole}
  979. onChange={e => setInviteRole(e.target.value)}
  980. >
  981. {Object.entries(ROLE_LABELS).map(([value, label]) => (
  982. <option key={value} value={value}>{label}</option>
  983. ))}
  984. </select>
  985. </div>
  986. <button
  987. type="button"
  988. disabled={inviting || !inviteEmail.trim()}
  989. onClick={handleCreateLink}
  990. className="btn btn-secondary btn-md"
  991. title="Create invite link and copy to clipboard"
  992. >
  993. {inviting ? 'Creating…' : (
  994. <span className="flex items-center gap-1.5">
  995. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  996. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  997. </svg>
  998. Copy Link
  999. </span>
  1000. )}
  1001. </button>
  1002. <button
  1003. type="submit"
  1004. disabled={inviting || !inviteEmail.trim()}
  1005. className="btn btn-primary btn-md"
  1006. title="Send invite"
  1007. >
  1008. {inviting ? 'Sending…' : 'Send Invite'}
  1009. </button>
  1010. </form>
  1011. {createdLink && (
  1012. <div className="rounded-lg p-4 animate-scale-in"
  1013. style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
  1014. <div className="flex items-center gap-2 mb-1.5">
  1015. <svg className="w-4 h-4 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1016. <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
  1017. </svg>
  1018. <span className="text-sm font-medium" style={{ color: '#86EFAC' }}>Invitation link created!</span>
  1019. <button
  1020. type="button"
  1021. onClick={async () => {
  1022. await safeCopy(createdLink);
  1023. setLinkCopiedAgain(true);
  1024. setTimeout(() => setLinkCopiedAgain(false), 2000);
  1025. }}
  1026. className="ml-auto text-xs px-3 py-1 rounded-lg transition-all"
  1027. style={{ background: 'rgba(255,255,255,0.06)', color: linkCopiedAgain ? '#86EFAC' : 'var(--text-muted)' }}
  1028. >
  1029. {linkCopiedAgain ? '✓ Copied' : 'Copy link'}
  1030. </button>
  1031. </div>
  1032. <p className="text-[10px] mb-2" style={{ color: 'rgba(134,239,172,0.5)' }}>
  1033. Invite sent to <strong style={{ color: '#86EFAC' }}>{createdLinkEmail}</strong> as {inviteRole} · Link expires in 7 days
  1034. </p>
  1035. <p className="text-xs break-all font-mono" style={{ color: 'rgba(134,239,172,0.7)' }}>
  1036. {createdLink}
  1037. </p>
  1038. <p className="text-[10px] mt-2" style={{ color: 'rgba(134,239,172,0.45)' }}>
  1039. Share this link with your colleague — they can use it to join the project directly.
  1040. </p>
  1041. </div>
  1042. )}
  1043. {inviteError && <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>}
  1044. {inviteSuccess && <p className="text-xs" style={{ color: '#86EFAC' }}>{inviteSuccess}</p>}
  1045. </div>
  1046. </div>
  1047. )}
  1048. {/* Members list */}
  1049. <div className="card overflow-hidden mb-6">
  1050. <div className="px-5 py-4 border-b" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  1051. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  1052. Members ({members.length})
  1053. </h2>
  1054. </div>
  1055. {members.length === 0 ? (
  1056. <div className="p-8 text-center">
  1057. <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No members yet</p>
  1058. </div>
  1059. ) : (
  1060. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1061. {members.map(m => {
  1062. const isMe = m.user.id === user?.id;
  1063. const canEdit = isAdmin && !isMe;
  1064. return (
  1065. <div key={m.id}
  1066. className="flex items-center gap-4 px-5 py-4 hover:bg-white/[0.02] transition-colors">
  1067. <Avatar name={m.user.name} src={m.user.avatarUrl} size="md" />
  1068. <div className="flex-1 min-w-0">
  1069. <div className="flex items-center gap-2">
  1070. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
  1071. {m.user.name}
  1072. {isMe && <span className="ml-1.5 text-[10px]" style={{ color: 'var(--text-subtle)' }}>(you)</span>}
  1073. </span>
  1074. </div>
  1075. <p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{m.user.email}</p>
  1076. </div>
  1077. <span className="text-xs hidden sm:block" style={{ color: 'var(--text-subtle)' }}>
  1078. {new Date(m.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
  1079. </span>
  1080. {editingRoleId === m.id ? (
  1081. <div className="flex items-center gap-2 shrink-0">
  1082. <select
  1083. className="input text-xs py-1.5"
  1084. value={editingRole}
  1085. onChange={e => setEditingRole(e.target.value)}
  1086. autoFocus
  1087. >
  1088. {Object.entries(ROLE_LABELS).map(([v, l]) => (
  1089. <option key={v} value={v}>{l}</option>
  1090. ))}
  1091. </select>
  1092. <button onClick={() => handleChangeRole(m.id)} disabled={updatingRole} className="btn btn-primary btn-sm px-2" title="Save">
  1093. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1094. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1095. </svg>
  1096. </button>
  1097. <button onClick={() => setEditingRoleId(null)} className="btn btn-secondary btn-sm px-2" title="Cancel">
  1098. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1099. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1100. </svg>
  1101. </button>
  1102. </div>
  1103. ) : (
  1104. <div className="flex items-center gap-2 shrink-0">
  1105. <span className={`badge ${ROLE_COLORS[m.role] ?? 'badge-muted'}`}>
  1106. {ROLE_LABELS[m.role] ?? m.role}
  1107. </span>
  1108. {canEdit && (
  1109. <button
  1110. onClick={() => { setEditingRoleId(m.id); setEditingRole(m.role); }}
  1111. className="btn btn-secondary btn-sm"
  1112. title="Change role"
  1113. >
  1114. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1115. <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
  1116. </svg>
  1117. </button>
  1118. )}
  1119. {isAdmin && !isMe && (
  1120. <button
  1121. onClick={() => setConfirmRemove({ id: m.user.id, name: m.user.name })}
  1122. className="btn btn-danger btn-sm"
  1123. title="Remove from project"
  1124. >
  1125. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1126. <path strokeLinecap="round" strokeLinejoin="round" d="M22 12h-4l-3 9L9 3l-3 9H2" />
  1127. </svg>
  1128. </button>
  1129. )}
  1130. </div>
  1131. )}
  1132. </div>
  1133. );
  1134. })}
  1135. </div>
  1136. )}
  1137. </div>
  1138. {/* Pending invitations */}
  1139. {canManage && (
  1140. <div className="card overflow-hidden">
  1141. <div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
  1142. <h2 className="text-sm font-semibold" style={{ color: 'var(--text)' }}>
  1143. Pending invitations
  1144. </h2>
  1145. <span className="text-xs px-2 py-0.5 rounded-full"
  1146. style={{ background: 'rgba(255,255,255,0.05)', color: 'var(--text-muted)' }}>
  1147. {pendingInvites.length}
  1148. </span>
  1149. </div>
  1150. {pendingInvites.length === 0 ? (
  1151. <div className="p-8 text-center">
  1152. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>No pending invitations</p>
  1153. </div>
  1154. ) : (
  1155. <div className="divide-y" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1156. {pendingInvites.map(inv => (
  1157. <div key={inv.id}
  1158. className="flex items-center gap-4 px-5 py-4">
  1159. <div className="w-9 h-9 rounded-full flex items-center justify-center shrink-0"
  1160. style={{ background: 'rgba(99,102,241,0.08)' }}>
  1161. <svg className="w-4 h-4" style={{ color: '#818CF8' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
  1162. <path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
  1163. </svg>
  1164. </div>
  1165. <div className="flex-1 min-w-0">
  1166. <div className="flex items-center gap-2">
  1167. <span className="text-sm font-medium" style={{ color: 'var(--text)' }}>{inv.email}</span>
  1168. <span className={`badge ${ROLE_COLORS[inv.role] ?? 'badge-muted'}`}>
  1169. {ROLE_LABELS[inv.role] ?? inv.role}
  1170. </span>
  1171. </div>
  1172. <div className="flex items-center gap-3 mt-0.5 text-xs" style={{ color: 'var(--text-subtle)' }}>
  1173. <span>Sent {new Date(inv.createdAt).toLocaleDateString()}</span>
  1174. <span>·</span>
  1175. <span>Expires {new Date(inv.expiresAt).toLocaleDateString()}</span>
  1176. </div>
  1177. </div>
  1178. <div className="flex items-center gap-1.5 shrink-0">
  1179. <button
  1180. onClick={() => handleCopyLink(inv)}
  1181. className="btn btn-secondary btn-sm"
  1182. title="Copy invite link"
  1183. >
  1184. {copiedInviteId === inv.id ? (
  1185. <svg className="w-3.5 h-3.5" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1186. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  1187. </svg>
  1188. ) : (
  1189. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1190. <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
  1191. </svg>
  1192. )}
  1193. </button>
  1194. <button
  1195. onClick={() => handleRevoke(inv.id)}
  1196. disabled={revokingId === inv.id}
  1197. className="btn btn-danger btn-sm"
  1198. title="Revoke invitation"
  1199. >
  1200. {revokingId === inv.id ? '…' : (
  1201. <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1202. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  1203. </svg>
  1204. )}
  1205. </button>
  1206. </div>
  1207. </div>
  1208. ))}
  1209. </div>
  1210. )}
  1211. {pendingInvites.length > 0 && (
  1212. <div className="px-5 py-3 border-t" style={{ borderColor: 'rgba(255,255,255,0.04)' }}>
  1213. <p className="text-xs" style={{ color: 'var(--text-subtle)' }}>
  1214. Invitation links expire after 7 days. Copy the link and send it manually, or ask the recipient to check their email.
  1215. </p>
  1216. </div>
  1217. )}
  1218. </div>
  1219. )}
  1220. </div>
  1221. )}
  1222. </div>
  1223. {/* Share modal */}
  1224. {sharingAssetId && (
  1225. <ShareModal
  1226. assetId={sharingAssetId}
  1227. onClose={() => setSharingAssetId(null)}
  1228. />
  1229. )}
  1230. {/* Delete asset confirm modal */}
  1231. {confirmDelete && (
  1232. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1233. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1234. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1235. <div className="flex items-center gap-3 mb-4">
  1236. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  1237. style={{ background: 'rgba(248,113,113,0.15)' }}>
  1238. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1239. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  1240. </svg>
  1241. </div>
  1242. <div>
  1243. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>Delete video?</h3>
  1244. <p className="text-xs mt-0.5 truncate max-w-[220px]" style={{ color: 'var(--text-muted)' }}>
  1245. "{confirmDelete.title}"
  1246. </p>
  1247. </div>
  1248. </div>
  1249. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1250. This will permanently delete the video file, thumbnail, and all HLS segments. This action cannot be undone.
  1251. </p>
  1252. <div className="flex gap-3 justify-end">
  1253. <button onClick={() => setConfirmDelete(null)} disabled={!!deletingId} className="btn btn-secondary btn-md">Cancel</button>
  1254. <button onClick={confirmDeleteAsset} disabled={!!deletingId} className="btn btn-danger btn-md">
  1255. {deletingId === confirmDelete.id ? 'Deleting…' : 'Delete video'}
  1256. </button>
  1257. </div>
  1258. </div>
  1259. </div>
  1260. )}
  1261. {/* Remove member confirm modal */}
  1262. {confirmRemove && (
  1263. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1264. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1265. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1266. <h3 className="text-base font-semibold mb-2" style={{ color: 'var(--text)' }}>
  1267. Remove {confirmRemove.name}?
  1268. </h3>
  1269. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1270. They'll lose access to this project and all its videos. They can rejoin if invited again.
  1271. </p>
  1272. <div className="flex gap-3 justify-end">
  1273. <button onClick={() => setConfirmRemove(null)} className="btn btn-secondary btn-md">Cancel</button>
  1274. <button onClick={handleRemoveMember} disabled={removing} className="btn btn-danger btn-md">
  1275. {removing ? 'Removing…' : 'Remove'}
  1276. </button>
  1277. </div>
  1278. </div>
  1279. </div>
  1280. )}
  1281. {/* Delete project confirm modal */}
  1282. {confirmDeleteProject && (
  1283. <div className="fixed inset-0 z-50 flex items-center justify-center"
  1284. style={{ background: 'rgba(0,0,0,0.7)' }}>
  1285. <div className="card p-6 max-w-sm w-full mx-4 animate-scale-in">
  1286. <div className="flex items-center gap-3 mb-4">
  1287. <div className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
  1288. style={{ background: 'rgba(248,113,113,0.15)' }}>
  1289. <svg className="w-5 h-5" style={{ color: '#F87171' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  1290. <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
  1291. </svg>
  1292. </div>
  1293. <div>
  1294. <h3 className="text-base font-semibold" style={{ color: 'var(--text)' }}>
  1295. Delete "{project?.name}"?
  1296. </h3>
  1297. <p className="text-xs mt-0.5" style={{ color: '#F87171' }}>
  1298. {assets.length} video{assets.length !== 1 ? 's' : ''} will be permanently deleted
  1299. </p>
  1300. </div>
  1301. </div>
  1302. <p className="text-sm mb-5" style={{ color: 'var(--text-muted)' }}>
  1303. This will permanently delete the project, all videos, comments, and assets. This action cannot be undone.
  1304. </p>
  1305. <div className="flex gap-3 justify-end">
  1306. <button
  1307. onClick={() => setConfirmDeleteProject(false)}
  1308. disabled={deletingProject}
  1309. className="btn btn-secondary btn-md"
  1310. >
  1311. Cancel
  1312. </button>
  1313. <button
  1314. onClick={handleDeleteProject}
  1315. disabled={deletingProject}
  1316. className="btn btn-danger btn-md"
  1317. >
  1318. {deletingProject ? 'Deleting…' : 'Delete Project'}
  1319. </button>
  1320. </div>
  1321. </div>
  1322. </div>
  1323. )}
  1324. </div>
  1325. );
  1326. }